<?php

abstract class Model_Document
{

	/**
	 * collection class name
	 * @var string
	 */
	protected $_collection = null;

	/**
	 * @var string
	 */
	protected $_db = null;

	/**
	 * Whether or not this collection is a gridFS collection If using a
	 * corresponding Mongo_Collection subclass, set this only in the
	 * Mongo_Collection subclass.
	 *
	 *  @var  boolean
	 */
	protected $_gridFS = FALSE;

	/**
	 * Designated place for non-persistent data storage (will not be saved to
	 * the database or after sleep)
	 *
	 *  @var  array
	 */
	public $__data = array();

	/**
	 * Internal storage of object data
	 *
	 *  @var  array
	 */
	protected $_data = array();

	/**
	 *  Keep track of fields changed using __set or loadValues
	 *
	 *  @var  array
	 */
	protected $_changed = array();

	/**
	 *  Set of operations to perform on update/insert
	 *
	 *  @var  array
	 */
	protected $_operation = array();

	/**
	 *  @var  boolean
	 */
	protected $_loaded = NULL;
	
	/**
	 * @var array
	 */
	protected $_dirty = array();

	/**
	 *
	 * @param   string  model name
	 * @param   mixed   optional _id of document to operate on or criteria for
	 * @return  Mongo_Document
	 */
	public static function factory($name, $id = NULL)
	{
		$class = 'Model_' . implode('_', array_map('ucfirst', explode('_', $name)));
		return new $class($id);
	}

	/**
	 * Instantiate a new Document object. If an id or other data is passed then
	 * it will be assumed that the
	 * document exists in the database and updates will be performaed without
	 * loading the document first.
	 *
	 * @param   string  The _id of the document to operate on or criteria used to
	 * load
	 * @return  void
	 */
	public function __construct($id = NULL)
	{
		if ($id !== NULL)
		{
			if (is_array($id))
			{
				$this -> _data = $id;
			}
			else
			{
				$this -> _data['_id'] = $id;
			}
		}
	}

	/**
	 * Returns the attributes that should be serialized.
	 *
	 * @return  void
	 */
	public function __sleep()
	{
		return array(
			'_data',
			'_changed',
			'_operation',
			'_loaded'
		);
	}

	/**
	 * Checks if a field is set
	 *
	 * @return  boolean  field is set
	 */
	public function __isset($key)
	{
		return isset($this -> _data[$key]);
	}

	/**
	 * Unset a field
	 *
	 * @return  void
	 */
	public function __unset($key)
	{
		if (isset($this -> _data[$key]))
		{
			unset($this -> _data[$key]);
			$this -> _dirty[$key] = TRUE;
		}
	}

	/**
	 * Clear the document data
	 *
	 * @return  Mongo_Document
	 */
	public function clear()
	{
		$this -> _data = $this -> _changed = $this -> _operation = array();
		$this -> _loaded = NULL;
		return $this;
	}

	/**
	 * Return TRUE if field has been changed
	 *
	 * @param   string   $name  field name (no parameter returns TRUE if there
	 * are *any* changes)
	 * @return  boolean  field has been changed
	 */
	public function is_changed($key = NULL)
	{
		if ($key === NULL)
		{
			return ($this -> _changed || $this -> _operation);
		}
		else
		{
			return isset($this -> _changed[$key]);
		}
	}

	/**
	 * Gets one of the following:
	 *
	 *  - A referenced object
	 *  - A search() result
	 *  - A field's value
	 *
	 * @param   string  field name
	 * @return  mixed
	 */
	public function __get($key)
	{
		if ($this -> _loaded === NULL && $key != '_id')
		{
			$this -> load();
		}

		return isset($this -> _data[$key]) ? $this -> _data[$key] : NULL;
	}

	/**
	 * Magic method for setting the value of a field. In order to set the value
	 * of a nested field,
	 * you must use the "set" method, not the magic method. Examples:
	 *
	 * <code>
	 * // Works
	 * $doc->set('address.city', 'Knoxville');
	 *
	 * // Does not work
	 * $doc->address['city'] = 'Knoxville';
	 * </code>
	 *
	 * @param   string  field name
	 * @param   mixed   new field value
	 * @return  mixed
	 */
	public function __set($key, $value)
	{
		$this -> _data[$key] = $value;
		$this -> _changed[$key] = TRUE;
	}

	/**
	 * Set the value for a key. This function must be used when updating nested
	 * documents.
	 *
	 * @param   string  $name The key of the data to update (use dot notation for
	 * embedded objects)
	 * @param   mixed   $value The data to be saved
	 * @return  Mongo_Document
	 */
	public function _set($key, $value)
	{
		$this -> _operation['$set'][$key] = $value;
		$this -> _dirty[$key] = TRUE;
		return $this;
	}

	/**
	 * @param   string  $name The key of the data to update (use dot notation for
	 * embedded objects)
	 * @return Mongo_Document
	 */
	public function _unset($key)
	{
		$this -> _operation['$unset'][$key] = 1;
		$this -> _dirty[$key] = TRUE;
		return $this;
	}

	/**
	 * Increment a value atomically
	 *
	 * @param   string  $name The key of the data to update (use dot notation for
	 * embedded objects)
	 * @param   mixed   $value The amount to increment by (default is 1)
	 * @return  Mongo_Document
	 */
	public function inc($key, $value = 1)
	{
		$this -> _operation['$inc'][$key] = $value;
		$this -> _dirty[$key] = TRUE;
		return $this;
	}

	/**
	 * Push a vlaue to an array atomically. Can be called multiple times.
	 *
	 * @param   string  $name The key of the data to update (use dot notation for
	 * embedded objects)
	 * @param   mixed   $value The value to push
	 * @return  Mongo_Document
	 */
	public function push($key, $value)
	{
		$this -> _operation['$push'][$key] = $value;
		$this -> _dirty[$key] = TRUE;
		return $this;
	}

	/**
	 * Push an array of values to an array in the document
	 * @return  Mongo_Document
	 */
	public function pushAll($key, $value)
	{
		$this -> _operation['$pushAll'][$key] = $value;
		$this -> _dirty[$key] = TRUE;
		return $this;
	}

	/**
	 * Pop a value from the end of an array
	 *
	 * @param   string  $name The key of the data to update
	 * @return  Mongo_Document
	 */
	public function pop($key)
	{
		$this -> _operation['$pop'][$key] = 1;
		$this -> _dirty[$key] = TRUE;
		return $this;
	}

	/**
	 * Pop a value from the beginning of an array
	 *
	 * @param   string  $name The key of the data to update (use dot notation for
	 * embedded objects)
	 * @return  Mongo_Document
	 */
	public function shift($key)
	{
		$this -> _operation['$pop'][$key] = -1;
		$this -> _dirty[$key] = TRUE;
		return $this;
	}

	/**
	 * Pull (delete) a value from an array
	 *
	 * @param   string  $name The key of the data to update (use dot notation for
	 * embedded objects)
	 * @param   mixed   $value
	 * @return  Mongo_Document
	 */
	public function pull($key, $value)
	{
		$this -> _operation['$pull'][$key] = $value;
		$this -> _dirty[$key] = TRUE;
		return $this;
	}

	/**
	 * Pull (delete) all of the given values from an array
	 *
	 * @param   string  $name The key of the data to update (use dot notation for
	 * embedded objects)
	 * @param   array   $value An array of value to pull from the array
	 * @return  Mongo_Document
	 */
	public function pullAll($key, $value)
	{
		$this -> _operation['$pullAll'][$key] = $value;
		$this -> _dirty[$key] = TRUE;
		return $this;
	}

	/**
	 * Bit operators
	 *
	 * @param   string  $name The key of the data to update (use dot notation for
	 * embedded objects)
	 * @return  Mongo_Document
	 */
	public function bit($key, $value)
	{
		$this -> _operation['$bit'][$key] = $value;
		$this -> _dirty[$key] = TRUE;
		return $this;
	}

	/**
	 * Adds value to the array only if its not in the array already.
	 *
	 * @param   string  $name The key of the data to update (use dot notation for
	 * embedded objects)
	 * @param   mixed   $value  The value to add to the set
	 * @return  Mongo_Document
	 */
	public function addToSet($key, $value)
	{
		$this -> _operation['$addToSet'][$key] = $value;
		$this -> _dirty[$key] = TRUE;
		return $this;
	}

	/**
	 * Load all of the values in an associative array. Ignores all fields
	 * not in the model.
	 *
	 * @param   array    field => value pairs
	 * @param   boolean  values are clean (from database)?
	 * @return  Mongo_Document
	 */
	public function loadValues($values, $clean = FALSE)
	{
		$this -> _data = $values;
		$this -> _loaded = !empty($values);
		return $this;
	}

	/**
	 * Get the model data as an associative array.
	 * @return  array  field => value
	 */
	public function as_array()
	{
		return $this -> _data;
	}

	/**
	 * Return true if the document is loaded.
	 *
	 * @return  boolean
	 */
	public function is_loaded()
	{
		return $this -> _loaded;
	}

	/**
	 * Load the document from the database. The first parameter may be one of:
	 *
	 *  a falsey value - the object data will be used to construct the query
	 *  a JSON string - will be parsed and used for the query
	 *  an non-array value - the query will be assumed to be for an _id of this
	 * value
	 *  an array - the array will be used for the query
	 *
	 * @param   array  specify additional criteria
	 * @param   array  specify the fields to return
	 * @return  boolean  TRUE if the load succeeded
	 */
	public function load($criteria = null, array $fields = array())
	{

		// Use of json for querying is allowed
		if (is_string($criteria) OR is_numeric($criteria))
		{
			$criteria = array('_id' => $criteria);
		}
		else
		if (isset($this -> _data['_id']))
		{
			$criteria = array('_id' => $this -> _data['_id']);
		}

		$values = DbNosql::inst($this -> _db) -> findOne($this -> _collection, $criteria);

		// Only clear the object if necessary
		if ($this -> _loaded !== NULL || $this -> _changed || $this -> _operation)
		{
			$this -> clear();
		}

		$this -> loadValues($values, TRUE);
		
		return $this -> _loaded;
	}

	/**
	 * Save the document to the database. For newly created documents the _id
	 * will be retrieved.
	 *
	 * @param   boolean  $safe  If FALSE the insert status will not be checked
	 * @return  Mongo_Document
	 */
	public function save($safe = TRUE)
	{
		$db = DbNosql::inst($this -> _db);

		// Insert new record if no _id or _id was set by user
		if (!isset($this -> _data['_id']) || isset($this -> _changed['_id']))
		{

			$values = array();

			foreach ($this->_changed as $key => $_true)
			{
				$values[$key] = $this -> _data[$key];
			}

			if (empty($values))
			{
				throw new MongoException('Cannot insert empty array.');
			}

			$err = $db -> insert($this -> _collection, $values, array('safe' => $safe));

			if ($safe && $err['err'])
			{
				throw new MongoException('Unable to insert ' . get_class($this) . ': ' . $err['err']);
			}

			if (!isset($this -> _data['_id']))
			{
				// Store (assigned) MongoID in object
				$this -> _data['_id'] = $values['_id'];
				$this -> _loaded = TRUE;
			}

			// Save any additional operations
			/** @todo  Combine operations into the insert when possible to avoid
			 * this update */
			if ($this -> _operation)
			{
				if (!$db -> update($this -> _collection, array('_id' => $this -> _data['_id']), $this -> _operation))
				{
					$err = $db -> lastError();
					throw new MongoException('Update of ' . get_class($this) . ' failed: ' . $err['err']);
				}
			}
		}

		// Update assumed existing document
		else
		{
			if ($this -> _changed)
			{
				foreach ($this->_changed as $key => $_true)
				{
					$this -> _operations['$set'][$key] = $this -> _object[$key];
				}
			}

			if ($this -> _operation)
			{

				if (!$db -> update($this -> _collection, array('_id' => $this -> _data['_id']), $this -> _operation))
				{
					$err = $this -> db() -> lastError();
					throw new MongoException('Update of ' . get_class($this) . ' failed: ' . $err['err']);
				}
				$this -> _changed = $this -> _operation = array();
			}
		}

		return $this;
	}

	public function collection()
	{
		return DbNosql::inst($this -> _db) ->db()->selectCollection($this -> _collection);
	}

	/**
	 * Delete the current document using the current data. The document does not
	 * have to be loaded.
	 * Use $doc->collection()->remove($criteria) to delete multiple documents.
	 *
	 * @return  Mongo_Document
	 */
	public function delete($safe = TRUE)
	{

		if (!isset($this -> _data['_id']))
		{
			throw new MongoException('Cannot delete ' . get_class($this) . ' without the _id.');
		}

		$criteria = array('_id' => $this -> _data['_id']);

		if ($this -> collection() -> remove($criteria, array('safe' => $safe)))
		{
			throw new MongoException('Failed to delete ' . get_class($this));
		}

		$this -> clear();
		return $this;
	}

	/**
	 * array_merge_recursive_distinct does not change the datatypes of the values
	 * in the arrays.
	 * @param array $array1
	 * @param array $array2
	 * @return array
	 */
	protected static function array_merge_recursive_distinct(array &$array1, array &$array2)
	{
		$merged = $array1;

		foreach ($array2 as $key => &$value)
		{
			if (is_array($value) && isset($merged[$key]) && is_array($merged[$key]))
			{
				$merged[$key] = self::array_merge_recursive_distinct($merged[$key], $value);
			}
			else
			{
				$merged[$key] = $value;
			}
		}

		return $merged;
	}

	public function __getAutoincrement()
	{
		return DbNosql::inst() -> getAutoIncrement($this -> _collection);
	}

}
